Skip to content

Zoom the waveform lanes with the wheel, without changing what they look like - #493

Merged
thcp merged 3 commits into
mainfrom
feat/waveform-zoom
Aug 29, 2026
Merged

Zoom the waveform lanes with the wheel, without changing what they look like#493
thcp merged 3 commits into
mainfrom
feat/waveform-zoom

Conversation

@thcp

@thcp thcp commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Scroll over the waveform lanes to zoom between 1x and 5x, anchored on the
pointer. Shift-scroll pans. A track always opens fitted.

Closes #492

The bars do not change

This was the constraint worth designing around. Each lane is an SVG whose
viewBox width is the bar count, drawn with preserveAspectRatio="none", so
widening it without redrawing multiplies every bar's width by the zoom factor.
That reads as a zoom for about a second and then reads as a bug: the same
picture with fatter strokes, and no new information.

The bars are redrawn at the new count instead. Measured at 1600px:

1x 5x
Bars 182 910
Content width 910 px 4550 px
Pixels per bar 5.000 5.000

Same 3 px bar, same 2 px gap, same rounding. Five times the detail.

No new width formula was needed for it. .waves-column already sits inside
.wave-canvas, which is width: calc(100% * var(--zoom)), so
overviewBarCount() reads the zoomed width on its own. Redrawing after the
reflow is the entire fix.

Why 1x and 5x

5x is where the source runs out, not a round number. peaks.json carries 1500
points per stem, and past roughly 5x a typical panel asks for more bars than
there are points behind them. The extra bars then repeat their neighbours, which
looks like detail and is not. Where decoded buffers are already in RAM the peaks
are recomputed at the bar count rather than a fixed 1500, so a wide panel at 5x
gets real samples instead of stair-steps.

1x is a floor for the opposite reason: zoomed out further there is nothing
left to show, because the whole track is already on screen.

The ruler subdivides

Ticks are positioned as a percentage of the timeline, so without this the same
handful of labels simply spread across five screen widths and the ruler got less
useful the further in you went. It now picks from a 1/2/5/10/15/30/60 s ladder
with a 110 px minimum between labels.

1x keeps exactly the step it always had, and the footer strip, which always
shows the whole track, is untouched. Both are asserted.

Loops and zoom coexist

Loops can be created, edited and toggled at any zoom, and zoom never touches one
that already exists.

Nothing had to be built for the drag to be correct while zoomed.
timeFromClientX measures against the ruler's own bounding box, and the ruler is
width: calc(100% * var(--zoom)) translated by scrollLeft, so its box is the
whole zoomed timeline wherever it happens to be scrolled to. The region overlay
is positioned in percentages inside .waves-column, which carries the same
zoomed width, so it lands on the same span at every zoom by construction.

Both are asserted rather than assumed. A drag across the middle of a 5x view is
checked against the times the pointer was actually over, and a loop is checked
across a round trip to 5x and back for identical bounds, identical region
geometry, and still running.

Notes for review

The gesture. Plain scroll zooms, which is what was asked for. It means
scrolling over the lanes no longer scrolls the lane stack vertically; the mixer
column still does, and the two panes mirror each other. Moving zoom to
ctrl+scroll is a one-line change if that turns out to be the wrong trade.

No import cycle. The zoom is published through state.js rather than
imported: player.js already imports transport.js, and having the redraw go
the other way would have closed the loop. Same shape as setFooterWaveDrawFn
directly above it.

Verification

75 browser tests pass, nine of them new. The two that carry the feature were
checked against a deliberately broken build first: disabling the redraw fails
the bar-width test, removing the gate fails the loop test.

i18n complete at 469 keys across all nine tables. node --check clean.

The streaming path is not covered. It draws WaveSurfer canvases instead of
the SVG and gets its own zoom call, whose bars are configured in pixels so a
re-render keeps them the same width by construction. I could not assert it: the
e2e fixture produces zero canvases in that mode. I checked that against main's
frontend with this branch's changes removed and got zero there too, so it
predates this work. Worth its own issue, since it means either the fixture
cannot exercise a whole render path or that path renders nothing.

Thales added 3 commits August 29, 2026 15:04
…ok like

Scroll over the lanes to zoom between 1x and 5x, anchored on the pointer so the
moment under the cursor stays under it. Shift-scroll pans. A track always opens
fitted.

The interesting constraint was the art. Each lane is an SVG whose viewBox width
is the bar count, drawn with preserveAspectRatio="none", so widening it without
redrawing multiplies every bar's width by the zoom factor: the same picture with
fatter strokes, which reads as a zoom for about a second and then reads as a
bug. The bars are redrawn at the new count instead. Measured at 1600px: 182 bars
over 910px at 1x, 910 bars over 4550px at 5x, and 5.000 pixels per bar at both.
Same 3px bar, same 2px gap, same rounding, five times the detail.

Nothing needed a new width formula for that. .waves-column already sits inside
.wave-canvas, which is width: calc(100% * var(--zoom)), so overviewBarCount()
reads the zoomed width on its own. Redrawing after the reflow is the whole fix.

5x is where the source runs out rather than a round number. peaks.json carries
1500 points per stem, and past roughly 5x a typical panel asks for more bars
than there are points behind them, at which point the extra bars repeat their
neighbours: detail that is not there. Where decoded buffers are in RAM the peaks
are recomputed at the bar count instead of a fixed 1500, so the wide-panel case
gets real samples rather than stair-steps. 1x is a floor for the opposite
reason: zoomed out further there is nothing left to show.

The ruler subdivides as you zoom, on a 1/2/5/10/15/30/60s ladder with a 110px
minimum between labels. Ticks are positioned as a percentage of the timeline, so
without this the same handful of labels simply spread across five screen widths
and the ruler got less useful the further in you went. 1x keeps exactly the step
it always had, and the footer strip, which always shows the whole track, is
untouched.

The loop tools are 1x only. A drag while zoomed would define a region whose ends
are off screen, and the overlay marks a percentage of a timeline the user can
see a fifth of. The button, the exact-loop fields and the drag are all inert
above 1x and say why in a tooltip. The bounds are never discarded: zooming back
restores the loop exactly as it was, including whether it was running.

The zoom is published through state.js rather than imported, because player.js
already imports transport.js and the redraw would have closed the cycle. Same
shape as setFooterWaveDrawFn next to it.

Nine browser tests. The two that carry the feature were checked against a broken
build first: disabling the redraw fails the bar-width test, removing the gate
fails the loop test.

The streaming path is not covered. It draws WaveSurfer canvases instead of the
SVG and gets its own zoom call, whose bars are configured in pixels so a
re-render keeps them the same width by construction. The e2e fixture produces no
canvases at all in that mode, which predates this and is worth its own look.

Closes #492
The first cut made the loop tools 1x-only, on the reasoning that a drag while
zoomed would define a region whose ends are off screen. That reasoning was
backwards: marking a loop precisely is one of the main reasons to zoom in at
all, and the ends being off screen is the user's business, not the app's.

Loops can now be created, edited and toggled at any zoom, and zoom never touches
one that already exists.

Nothing had to be built for the drag to be correct while zoomed.
timeFromClientX measures against the ruler's own bounding box, and the ruler is
width: calc(100% * var(--zoom)) translated by scrollLeft, so its box is the
whole zoomed timeline wherever it happens to be scrolled to. The region overlay
is positioned in percentages inside .waves-column, which carries the same zoomed
width, so it lands on the same span at every zoom by construction. Both are now
asserted rather than assumed: a drag across the middle of a 5x view is checked
against the times the pointer was actually over.

What went is the apparatus that was suppressing all this. syncLoopAvailability,
loopToolsAvailable, the remembered pre-zoom armed state, the disabled button and
fields, the zoom-locked styling, and position.loopZoomLocked in all nine
language tables.

The test that asserted the old behaviour now asserts its opposite: a loop
survives zooming to 5x and back with identical bounds, identical region geometry
and still running. Checked against a build that clears the loop on zoom, which
fails it.
Found by auditing the zoom rather than by hitting them, so each is stated with
what it would have cost.

The decoded overview was rescanned on every wheel notch. bufferMinMaxPeaks
walks every sample of every stem, and asking it for a resolution derived from
the current bar count meant re-reading the whole song per stem on each notch --
tens of millions of samples per step on a four minute track. It is now scanned
once per track at the finest resolution any zoom will ask for, and the bars are
downsampled from that. This also collapses the two overview sources into one:
peaks.json and the decoded buffers are the same shape from here on, they only
differ in how many points they carry, so the re-render no longer branches.

The anchor was derived from the zoom ratio, not the measured width.
WAVE_MIN_WIDTH floors the content at 720px, so on a narrow window a step can
widen the content by less than its own factor or not at all, and scaling the
scroll by the ratio slid the time out from under the pointer. Measured before
and after instead. Covered at 1150px, where the wave area is 460px and the
floor is genuinely active.

A resize left the ruler at the old width's tick density. Tick spacing is now
chosen from the content width, so it has to be reconsidered when that width
changes. The rebuild is guarded on the step actually differing: buildRuler
writes inside the element the resize observer watches, and rebuilding
unconditionally would feed the observer its own output.

Zooming with no time source parked the playhead at 0. buildRuler re-creates the
marker element, so it has to be put back where the transport is -- and if
nothing can say where that is, left alone rather than sent to the start.

Switching tracks painted the old loop for a frame. resetWaveZoom redraws the
loop region, and it ran before the previous track's loop had been cleared, so
the region was briefly drawn against the new track's duration. Moved after.

One more, in the pan gesture rather than the zoom: shift plus a horizontal
trackpad swipe reports deltaY 0, and the axis was picked in a way that resolved
to zero and scrolled nothing. It now takes whichever axis actually carried the
gesture.

Two test findings worth recording. The lane-body drag is unreliable until the
wave-loading overlay clears -- measured at three arms in six attempts on the
fixture, nothing to do with zoom -- so tests that drag on the lanes now wait for
it. And the two new anchor and performance guarantees were each checked against
a build with the old behaviour restored, which fails them.
@thcp
thcp merged commit ec756b9 into main Aug 29, 2026
10 checks passed
@thcp
thcp deleted the feat/waveform-zoom branch August 29, 2026 14:59
thcp added a commit that referenced this pull request Aug 29, 2026
I said in #493 that the streaming path was reasoned about rather than measured,
because looking for its canvases found none:

    document.querySelector("#multitrack-container").querySelectorAll("canvas")
    // 0

after eight seconds, with the overlay cleared, canplay fired and no failed
requests. That was convincing and wrong. WaveSurfer renders into shadow roots
and querySelectorAll does not cross a shadow boundary. Walking the roots finds
nine canvases, correctly sized, and a zoom re-renders them: 910px each at 1x,
and at 5x a 4550px lane cut into 4000 + 550, backing store matching CSS width
in every case.

So there was nothing to fix, and the gap was mine. The path is covered now, and
the assertion is the one that matters on it: WaveSurfer bars are configured in
pixels, so they keep their width across a zoom only if it re-renders rather than
letting a fixed-size canvas stretch. Backing width equal to CSS width is what
distinguishes the two, and a stretched canvas fails it.

The shadow-root walk lives in a helper with the trap written down next to it,
because the next person to look for a canvas here will otherwise reach the same
false conclusion.

Closes #494

Co-authored-by: Thales <>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The waveform lanes only ever show the whole track

1 participant